Skip to content

fix(db): correct the UTC skew and retire the stale outbox backlog - #245

Merged
Musiker15 merged 1 commit into
mainfrom
fix/notification-backlog-utc
Aug 17, 2026
Merged

fix(db): correct the UTC skew and retire the stale outbox backlog#245
Musiker15 merged 1 commit into
mainfrom
fix/notification-backlog-utc

Conversation

@Musiker15

Copy link
Copy Markdown
Member

Follow-up to #244. Both problems only became visible against production data, which is the honest summary of why they exist.

1. The backfill was two hours in the future

20260818003000 filled next_attempt_at with CURRENT_TIMESTAMP. That returns the session's local time, and the database runs Europe/Berlin while Prisma stores and reads timestamp without time zone as UTC. Every existing row therefore landed two hours ahead, and the poller's nextAttemptAt: { lte: now } stopped matching any of them.

Confirmed rather than assumed:

 TimeZone      | Europe/Berlin
 created_at    | 2026-07-03 06:32:59.562
 next_attempt_at | 2026-08-18 00:18:40.34     <- migration ran at 22:18 UTC

The symptom was the absence of one. After the deploy the bot logged no delivery attempts at all, where three failures were expected. Nothing was broken loudly enough to notice.

The rule going in the docs: a backfill of a Prisma DateTime uses now() AT TIME ZONE 'utc', never CURRENT_TIMESTAMP.

The column default is deliberately left as CURRENT_TIMESTAMP. It is what Prisma emits for @default(now()), so changing it would register as schema drift on the next migrate dev, and Prisma supplies the value on every insert, so the default is never actually reached.

2. Nineteen applicant DMs had been queued since July

What the retry bounds found waiting:

type pending oldest newest
message 12 2026-07-09 2026-08-06
status_change 7 2026-07-03 2026-07-11

19 rows against BATCH = 25. The outbox was six rows short of starving every newer notification, and had been filling for six weeks unnoticed, because a stalled outbox and a quiet one look identical from outside.

They are retired unsent rather than made due. Delivering them would have pushed six-week-old status notifications to applicants who have long since moved on, and the status page carries the current state at any time, so a very late nudge is worse than none. Expressed as an age rule (older than a week) rather than a list of ids, because the policy is the point; it happens to catch exactly tonight's 19.

last_error records why each row disappeared, so this is legible later instead of looking like data loss.

Verification

CI runs prisma migrate deploy against its own Postgres 16, so the SQL is exercised before merge. After deploy the two counts above should go to zero, and SELECT count(*) FROM notifications WHERE read_at IS NULL should track only genuinely fresh rows.

Follow-up to 20260818003000_notification_retry_bounds, which had two
problems that only showed up against production data.

The backfill used CURRENT_TIMESTAMP. That returns the session's local time,
and the database runs Europe/Berlin while Prisma stores and reads these
columns as UTC, so every existing row landed two hours in the future and the
poller stopped selecting any of them. Confirmed on the server: TimeZone
Europe/Berlin, next_attempt_at 00:18:40 local for a migration that ran at
22:18 UTC. The symptom was the absence of a symptom, no delivery attempts at
all where three were expected.

The column default stays as it is. CURRENT_TIMESTAMP is what Prisma emits
for @default(now()), changing it would read as schema drift, and Prisma
supplies the value on every insert so the default is never reached.

The second problem was what the retry bounds found waiting: 19 undeliverable
applicant DMs going back to 2026-07-03, against a batch size of 25. The
outbox was six rows short of starving every newer notification, and had been
filling up for six weeks without anyone noticing, because a stalled outbox
looks exactly like a quiet one.

Those rows are retired unsent rather than made due. Delivering them would
have sent six-week-old status notifications to applicants who have long since
moved on; the status page carries the current state at any time, so a very
late nudge is worse than none. Written as an age rule rather than a list of
ids, since it is the policy that matters, not tonight's 19 rows.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

Fixes the UTC skew from the 20260818003000_notification_retry_bounds backfill by recomputing next_attempt_at in UTC (now() AT TIME ZONE 'utc') instead of CURRENT_TIMESTAMP, which had pushed every queued row two hours into the future and stalled the poller. Retires unsent notifications older than 7 days (setting read_at and a last_error note) rather than delivering stale status DMs, then makes remaining unread rows due immediately by resetting next_attempt_at to created_at. Leaves the column DEFAULT untouched to avoid schema drift against Prisma's @default(now()).

No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 0 functions depend on the 0 functions this change touches.

Health — grade A; no new coupling hotspots.

Verification — 0 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

@Musiker15
Musiker15 merged commit ace84c4 into main Aug 17, 2026
6 checks passed
@Musiker15
Musiker15 deleted the fix/notification-backlog-utc branch August 17, 2026 22:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant